#!/usr/bin/env python3
#
# Moisture control - Graphical user interface
#
# Copyright (c) 2013 Michael Buesch <m@bues.ch>
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License along
# with this program; if not, write to the Free Software Foundation, Inc.,
# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
#

import sys

if sys.version_info[0] < 3:
	print("The Python interpreter is too old.")
	print("PLEASE INSTALL Python 3.x")
	raw_input("Press enter to exit.")
	sys.exit(1)

import math
from pymoistcontrol import *


# Serial communication parameters
SERIAL_BAUDRATE		= 19200
SERIAL_PAYLOAD_LEN	= 12


class MainWidget(QWidget):
	"""The central widget inside of the main window."""

	serialConnected = Signal()
	serialDisconnected = Signal()

	fetchCycle = [
		"log",
		"rtc",
		"pot_state",
	]

	def __init__(self, parent):
		"""Class constructor."""
		QWidget.__init__(self, parent)
		self.setLayout(QGridLayout(self))

		self.globConfWidget = GlobalConfigWidget(self)
		self.layout().addWidget(self.globConfWidget, 0, 0)

		self.tabWidget = QTabWidget(self)
		self.layout().addWidget(self.tabWidget, 0, 1)

		self.potWidgets = []
		for i in range(MAX_NR_FLOWERPOTS):
			potWidget = PotWidget(i, self)
			self.potWidgets.append(potWidget)
			self.tabWidget.addTab(potWidget,
					      "Pot %d" % (i + 1))
		self.setUiEnabled(False)

		self.logWidget = LogWidget(self)
		self.layout().addWidget(self.logWidget, 1, 0, 1, 2)

		self.connected = False
		self.pollTimer = QTimer(self)
		self.pollTimer.setSingleShot(True)

		self.globConfWidget.configChanged.connect(self.__handleGlobConfigChange)
		self.globConfWidget.rtcEdited.connect(self.__handleRtcEdit)
		for pot in self.potWidgets:
			pot.configChanged.connect(self.__handlePotConfigChange)
			pot.manModeChanged.connect(self.__handleManModeChange)
		self.pollTimer.timeout.connect(self.__pollTimerEvent)

	def __handleCommError(self, exception):
		"""Handle a serial communication error."""
		QMessageBox.critical(self,
				     "Serial communication failed",
				     "Serial communication failed:\n"
				     "%s" % str(exception))
		# Communication is down. Disconnect device.
		self.disconnectDev()

	def __makeMsg_GlobalConfig(self):
		"""Generate a "GlobalConfig" message and return it."""
		msg = MsgContrConf(flags = 0,
				   sensor_lowest_value = self.globConfWidget.lowestRawSensorVal(),
				   sensor_highest_value = self.globConfWidget.highestRawSensorVal())
		if self.globConfWidget.globalEnableActive():
			msg.flags |= msg.CONTR_FLG_ENABLE
		return msg

	def __makeMsg_RTC(self):
		"""Generate an "RTC" message and return it."""
		dateTime = self.globConfWidget.getRtcDateTime()
		msg = MsgRtc(second = dateTime.time().second(),
			     minute = dateTime.time().minute(),
			     hour = dateTime.time().hour(),
			     day = dateTime.date().day() - 1,
			     month = dateTime.date().month() - 1,
			     year = clamp(dateTime.date().year(), 2000, 2063) - 2000,
			     day_of_week = dateTime.date().dayOfWeek() - 1)
		return msg

	def __makeMsg_PotConfig(self, potNumber):
		"""Generate a "PotConfig" message and return it."""
		pot = self.potWidgets[potNumber]
		self.globConfWidget.handlePotEnableChange(potNumber,
							  pot.isEnabled())
		msg = MsgContrPotConf(pot_number = potNumber,
				      flags = 0,
				      min_threshold = pot.getMinThreshold(),
				      max_threshold = pot.getMaxThreshold(),
				      start_time = pot.getStartTime(),
				      end_time = pot.getEndTime(),
				      dow_on_mask = pot.getDowEnableMask())
		if pot.isEnabled():
			msg.flags |= msg.POT_FLG_ENABLED
		if pot.loggingEnabled():
			msg.flags |= msg.POT_FLG_LOG
		if pot.verboseLoggingEnabled():
			msg.flags |= msg.POT_FLG_LOGVERBOSE
		return msg

	def __makeMsg_ManMode(self):
		"""Generate a "ManMode" message and return it."""
		msg = MsgManMode(force_stop_watering_mask = 0,
				 valve_manual_mask = 0,
				 valve_manual_state = 0)
		for i, pot in enumerate(self.potWidgets):
			if pot.forceStopWateringActive():
				msg.force_stop_watering_mask |= 1 << i
			if pot.forceOpenValveActive():
				msg.valve_manual_mask |= 1 << i
				msg.valve_manual_state |= 1 << i
		return msg

	def __handleGlobConfigChange(self):
		"""Handle a change to any global configuration parameter."""
		try:
			self.serial.send(self.__makeMsg_GlobalConfig())
		except SerialError as e:
			self.__handleCommError(e)
			return

	def __handleRtcEdit(self):
		"""Handle a change to the RTC time."""
		try:
			self.serial.send(self.__makeMsg_RTC())
		except SerialError as e:
			self.__handleCommError(e)
			return

	def __handlePotConfigChange(self, potNumber):
		"""Handle a change to any pot configuration parameter."""
		try:
			self.serial.send(self.__makeMsg_PotConfig(potNumber))
		except SerialError as e:
			self.__handleCommError(e)
			return

	def __handleManModeChange(self):
		"""Handle a change to any manual-mode configuration parameter."""
		try:
			self.serial.send(self.__makeMsg_ManMode())
		except SerialError as e:
			self.__handleCommError(e)
			return

	def setUiEnabled(self, enabled = True):
		"""Enable/disable the user interface."""
		self.globConfWidget.setEnabled(enabled)
		for i in range(self.tabWidget.count()):
			self.tabWidget.widget(i).setEnabled(enabled)

	def __fetchCycleNext(self):
		"""Send the next "fetch"-message to the device."""
		action = self.fetchCycle[self.fetchCycleNumber]
		try:
			if action == "log":
				msg = MsgLogFetch()
			elif action == "rtc":
				msg = MsgRtcFetch()
			elif action == "pot_state":
				msg = MsgContrPotStateFetch(self.potCycleNumber)
			else:
				assert(0)
			self.serial.send(msg)
		except SerialError as e:
			self.__handleCommError(e)
			return
		# Start the RX poll timer, based on the calculated frame time.
		self.pollTimer.start(math.ceil(msg.calcFrameDuration() * 1000) + 10)

	def __checkRxMsg(self, msg, expectedType, ignoreErrorCode=False):
		"""Check a received message for validity.
		msg: The received message.
		expectedType: The expected type 'msg'.
		ignoreErrorCode: If true, a message error code != OK will
		                 not cause the check to fail."""
		ok = True
		if not ignoreErrorCode:
			if msg.getErrorCode() != Message.COMM_ERR_OK:
				QMessageBox.critical(self,
					"Received message: Error",
					"Received an error: %d" % \
					msg.getErrorCode())
				ok = False
		if ok and\
		   msg.getType() is not None and\
		   msg.getType() != expectedType:
			QMessageBox.critical(self,
				"Received message: Unexpected type",
				"Received a message with an unexpected "
				"type. (got %d, expected %d)" % \
				(msg.getType(), expectedType))
			ok = False
		if not ok:
			# The received message is flaky. Break connection.
			self.disconnectDev()
		return ok

	def __convertRxMsg(self, msg, fatalOnNoMsg=False):
		"""Convert a received raw message to a high-level message object."""
		msg = Message.fromRawMessage(msg)
		if not msg:
			if fatalOnNoMsg:
				QMessageBox.critical(self, "Communication failed",
					"Serial communication timeout")
				self.disconnectDev()
			return None
		error = msg.getErrorCode()
		if error not in (Message.COMM_ERR_OK, Message.COMM_ERR_FAIL):
			QMessageBox.critical(self, "Communication failed",
				"Serial communication error: %d" % error)
			self.disconnectDev()
			return None
		return msg

	def __pollTimerEvent(self):
		"""Receive poll timer event.
		Checks the serial line for received messages."""
		try:
			msg = self.__convertRxMsg(self.serial.poll())
			if not msg:
				self.__pollRetries += 1
				if self.__pollRetries >= 200:
					QMessageBox.critical(self,
						"Communication failed",
						"Communication failed. "
						"Retry timeout.")
					self.disconnectDev()
					return
				self.pollTimer.start(5) # Retry
				return
			self.__pollRetries = 0
		except SerialError as e:
			self.__handleCommError(e)
			return
		# We got a message
		error = msg.getErrorCode()

		advanceFetchCycle = True
		action = self.fetchCycle[self.fetchCycleNumber]
		if action == "log":
			if self.__checkRxMsg(msg, Message.MSG_LOG,
					     ignoreErrorCode = True):
				if error == Message.COMM_ERR_OK:
					self.logWidget.handleLogMessage(msg)
					self.logCount += 1
					if self.logCount < 8:
						advanceFetchCycle = False
			if advanceFetchCycle:
				self.logCount = 0
		elif action == "rtc":
			if self.__checkRxMsg(msg, Message.MSG_RTC):
				self.globConfWidget.handleRtcMessage(msg)
		elif action == "pot_state":
			if self.__checkRxMsg(msg, Message.MSG_CONTR_POT_STATE):
				if msg.pot_number == self.potCycleNumber:
					self.globConfWidget.handlePotStateMessage(msg)
					self.potWidgets[self.potCycleNumber].handlePotStateMessage(msg)
				else:
					QMessageBox.critical(self,
						"Pot state message mismatch",
						"Received pot state message for the"
						"wrong pot (was %d, expected %d)." % \
						(msg.pot_number, self.potCycleNumber))
			self.potCycleNumber += 1
			if self.potCycleNumber < MAX_NR_FLOWERPOTS:
				advanceFetchCycle = False
			else:
				self.potCycleNumber = 0
		else:
			assert(0)

		if advanceFetchCycle:
			# Increment the fetch cycle number to the next action.
			self.fetchCycleNumber += 1
			if self.fetchCycleNumber >= len(self.fetchCycle):
				self.fetchCycleNumber = 0
			self.__fetchCycleNext()
		else:
			self.__fetchCycleNext()

	def __initializeDev(self):
		"""Initialize the device.
		Fetch configuration and status from device."""
		self.fetchCycleNumber = 0
		self.potCycleNumber = 0
		self.logCount = 0
		self.__pollRetries = 0
		try:
			# Get the global configuration from the device
			msg = self.__convertRxMsg(self.serial.sendSync(MsgContrConfFetch()),
						  fatalOnNoMsg = True)
			if not self.__checkRxMsg(msg, Message.MSG_CONTR_CONF):
				return
			self.globConfWidget.handleGlobalConfMessage(msg)
			# Get the pot configurations from the device
			for i in range(MAX_NR_FLOWERPOTS):
				msg = self.__convertRxMsg(self.serial.sendSync(MsgContrPotConfFetch(i)),
							  fatalOnNoMsg = True)
				if not self.__checkRxMsg(msg, Message.MSG_CONTR_POT_CONF):
					return
				self.potWidgets[i].handlePotConfMessage(msg)
				self.globConfWidget.handlePotConfMessage(msg)
			# Reset manual mode
			msg = MsgManMode(force_stop_watering_mask = 0,
					 valve_manual_mask = 0,
					 valve_manual_state = 0)
			self.serial.send(msg)
			# Start cyclic data fetching
			self.__fetchCycleNext()
		except SerialError as e:
			self.__handleCommError(e)
			return False
		return True

	def isConnected(self):
		return self.connected

	def connectDev(self, port=None):
		"""Connect to the device, via serial line."""
		if self.connected:
			return
		if not port:
			dlg = SerialOpenDialog(self)
			if dlg.exec_() != QDialog.Accepted:
				return
			port = dlg.getSelectedPort()
		try:
			self.serial = SerialComm(port, baudrate = SERIAL_BAUDRATE,
						 payloadLen = SERIAL_PAYLOAD_LEN,
						 debug = False)
		except SerialError as e:
			QMessageBox.critical(self, "Cannot connect serial port",
				"Cannot connect serial port:\n" + str(e))
			return
		self.logWidget.clear()
		self.setUiEnabled(True)
		if not self.__initializeDev():
			return
		self.connected = True
		self.serialConnected.emit()

	def disconnectDev(self):
		"""Disconnect the current device connection."""
		self.setUiEnabled(False)
		self.pollTimer.stop()
		if self.serial:
			self.serial.close()
			self.serial = None
		if self.connected:
			self.connected = False
			self.serialDisconnected.emit()

	def getSettingsText(self):
		"""Get a text representation of the current configuration."""
		settings = [
			"[MOISTCONTROL_SETTINGS]\n" \
			"file_version=0\n" \
			"date=%s\n" % \
			(QDateTime.currentDateTime().toUTC().toString("yyyy.MM.dd_hh:mm:ss.zzz_UTC"))
		]
		# Write global config
		msg = self.__makeMsg_GlobalConfig()
		settings.append(msg.toText())
		# Write pot configs
		for i in range(MAX_NR_FLOWERPOTS):
			msg = self.__makeMsg_PotConfig(i)
			settings.append(msg.toText())
		return "\n".join(settings)

	def setSettingsText(self, settings):
		"""Apply a text representation of the configuration to the device."""
		# Drain pending RX-messages
		self.pollTimer.stop()
		time.sleep(0.1)
		while self.serial.poll():
			pass
		# Parse and upload the new config
		try:
			p = configparser.ConfigParser()
			p.read_string(settings)
			ver = p.getint("MOISTCONTROL_SETTINGS", "file_version")
			if ver != 0:
				raise Error("Unsupported file version. "
					    "Expected v0, but got v%d." % ver)
			# Read global config
			msg = MsgContrConf()
			msg.fromText(settings)
			self.serial.send(msg) # send to device
			# Read pot configs
			for i in range(MAX_NR_FLOWERPOTS):
				msg = MsgContrPotConf(i)
				msg.fromText(settings)
				self.serial.send(msg) # send to device
		except configparser.Error as e:
			raise Error(str(e))
		except SerialError as e:
			raise Error("Failed to send config to device:\n" % str(e))
		finally:
			# Restart the communication
			self.__initializeDev()

	def doLoadSettings(self, filename):
		"""Load configuration settings from a named file."""
		try:
			fd = open(filename, "rb")
			settings = fd.read().decode("UTF-8")
			fd.close()
			self.setSettingsText(settings)
		except (IOError, UnicodeError, Error) as e:
			QMessageBox.critical(self,
				"Failed to read file",
				"Failed to read the settings file:\n"
				"%s" % str(e))

	def loadSettings(self):
		"""Load configuration settings from a file."""
		fn, filt = QFileDialog.getOpenFileName(self,
				"Load settings from file",
				"",
				"Settings file (*.moi);;"
				"All files (*)")
		if not fn:
			return
		self.doLoadSettings(fn)

	def doSaveSettingsAs(self, filename):
		"""Save configuration settings to a named file."""
		try:
			fd = open(filename, "wb")
			fd.write(self.getSettingsText().encode("UTF-8"))
			fd.close()
		except (IOError, UnicodeError, Error) as e:
			QMessageBox.critical(self,
				"Failed to write file",
				"Failed to write the settings file:\n"
				"%s" % str(e))

	def saveSettingsAs(self):
		"""Save configuration settings to a file."""
		fn, filt = QFileDialog.getSaveFileName(self,
				"Save settings to file",
				"",
				"Settings file (*.moi);;"
				"All files (*)")
		if not fn:
			return
		if "(*.moi)" in filt:
			if not fn.endswith(".moi"):
				fn += ".moi"
		self.doSaveSettingsAs(fn)

class MainWindow(QMainWindow):
	"""The main program window."""

	def __init__(self):
		"""Class constructor."""
		QMainWindow.__init__(self)
		self.setWindowTitle("Flowerpot moisture control")

		mainWidget = MainWidget(self)
		self.setCentralWidget(mainWidget)

		self.setMenuBar(QMenuBar(self))

		menu = QMenu("&File", self)
		self.loadButton = menu.addAction("&Load settings...", self.loadSettings)
		self.saveButton = menu.addAction("&Save settings as...", self.saveSettingsAs)
		menu.addSeparator()
		menu.addAction("&Exit", self.close)
		self.menuBar().addMenu(menu)

		menu = QMenu("&Device", self)
		self.connMenuButton = menu.addAction("&Connect", self.connectDev)
		self.disconnMenuButton = menu.addAction("&Disconnect", self.disconnectDev)
		self.menuBar().addMenu(menu)

		toolBar = QToolBar(self)
		self.connToolButton = toolBar.addAction("Connect", self.connectDev)
		self.disconnToolButton = toolBar.addAction("Disconnect", self.disconnectDev)
		self.addToolBar(toolBar)

		self.updateConnButtons()

		mainWidget.serialConnected.connect(self.updateConnButtons)
		mainWidget.serialDisconnected.connect(self.updateConnButtons)

	def updateConnButtons(self):
		"""Update the enable-status of the connect buttons."""
		connected = self.centralWidget().isConnected()
		self.connMenuButton.setEnabled(not connected)
		self.connToolButton.setEnabled(not connected)
		self.disconnMenuButton.setEnabled(connected)
		self.disconnToolButton.setEnabled(connected)
		self.loadButton.setEnabled(connected)
		self.saveButton.setEnabled(connected)

	def loadSettings(self):
		self.centralWidget().loadSettings()

	def saveSettingsAs(self):
		self.centralWidget().saveSettingsAs()

	def connectDev(self, port=None):
		self.centralWidget().connectDev(port)

	def disconnectDev(self):
		self.centralWidget().disconnectDev()

# Program entry point
def main():
	# Create the main QT application object
	qApp = QApplication(sys.argv)
	# Create and show the main window
	mainWnd = MainWindow()
	mainWnd.show()
	if len(sys.argv) >= 2:
		mainWnd.connectDev(sys.argv[1])
	# Enter the QT event loop
	return qApp.exec_()

if __name__ == "__main__":
	sys.exit(main())
